基础表格组件封装
上节课完成了需求调研,这节课进入实战阶段——基于 Element Plus 的 el-table 封装一个业务级的基础表格组件。封装后的组件需要同时包含表格(table)和分页(pagination),通过 props 一次性配置完成,避免每个页面都重复写一堆 el-table-column 和 el-pagination 的模板代码。
常见 PC 端表格使用场景分析
在动手封装之前,先梳理一下管理后台中表格页面的典型结构。一个标准的表格页面通常包含三个区域:
┌──────────────────────────────────┐
│ 表单搜索区(Form) │ ← 不属于表格组件的封装范围
├──────────────────────────────────┤
│ 工具栏(标题 + 操作按钮) │ ← 表格组件的 header 区域
├──────────────────────────────────┤
│ 表格内容区(Table) │ ← 核心区域
│ ┌────┬────┬────┬────┬────┐ │
│ │ 选择 │ 姓名 │ 日期 │ 状态 │ 操作 │ │
│ ├────┼────┼────┼────┼────┤ │
│ │ □ │ ... │ ... │ ... │ 编辑 │ │
│ └────┴────┴────┴────┴────┘ │
├──────────────────────────────────┤
│ 分页区(Pagination) │ ← 表格组件的 footer 区域
└──────────────────────────────────┘
text
表格组件需要支持的常见功能包括:固定操作列、固定表头、添加/删除行、多选/单选、排序/筛选等。进阶场景还包括:单元格内容格式化(数据库存 0/1 但显示为标签/开关)、行内编辑、展开详情行等。
项目结构搭建
创建组件文件
在 src/components 目录下创建 table 文件夹。由于 table 是 HTML 保留关键字,组件命名需要加前缀,这里使用 v-table:
src/components/
└── table/
├── v-table.vue # 基础表格组件
└── types.ts # 类型定义文件
text
基础模板结构
组件的核心模板只有两部分:el-table 和 el-pagination:
<!-- src/components/table/v-table.vue -->
<template>
<div class="v-table-wrapper">
<el-table v-bind="tableProps">
<el-table-column
v-for="(column, index) of columns"
:key="index"
v-bind="column"
/>
</el-table>
<div class="p-2" :class="paginationClass">
<el-pagination v-bind="paginationProps" />
</div>
</div>
</template>
vue
关键设计点:使用 v-bind="tableProps" 和 v-bind="paginationProps" 实现属性透传,这样 el-table 和 el-pagination 支持的所有属性、事件都能直接透传下去,不需要一个个手动绑定。
TypeScript 类型定义
类型定义是封装组件的基石。将类型抽离到独立文件中,方便在其他页面复用。
类型文件
// src/components/table/types.ts
import type { TableProps } from 'element-plus'
import type { TableColumnCtx } from 'element-plus/es/components/table/src/table-column/defaults'
// 表格列类型别名
export type TableColumnType = TableColumnCtx<any>
// 扩展的分页属性(增加 align 控制布局方向)
export interface PaginationExtendedProps {
align?: 'left' | 'center' | 'right'
}
// 分页对齐方向
export type PaginationAlign = 'left' | 'center' | 'right'
typescript
组件 Props 定义
// src/components/table/v-table.vue
<script setup lang="ts">
import { computed, withDefaults } from 'vue'
import type { TableProps } from 'element-plus'
import type { PaginationProps } from 'element-plus'
import type { TableColumnType, PaginationAlign } from './types'
interface VTableProps {
// 继承 el-table 的所有属性
tableProps?: TableProps<any>
// 列配置
columns?: TableColumnType[]
// 分页配置(继承 el-pagination 属性 + 扩展 align)
pagination?: PaginationProps & { align?: PaginationAlign } & Record<string, any>
}
const props = withDefaults(defineProps<VTableProps>(), {
tableProps: () => ({
border: false,
stripe: false,
fit: true,
showHeader: true,
highlightCurrentRow: false,
emptyText: 'No Data',
defaultExpandAll: false,
tooltipEffect: 'dark',
showSummary: false,
tableLayout: 'fixed',
}),
columns: () => [],
pagination: () => ({
align: 'right',
}),
})
// 计算分页容器的 CSS 类名
const paginationClass = computed(() => {
const align = props.pagination?.align
let className = 'flex '
if (align === 'left') {
className += 'justify-start'
} else if (align === 'right') {
className += 'justify-end'
} else {
className += 'justify-center'
}
return className
})
</script>
typescript
注意 TableProps<any> 中的 any。TableProps 是一个泛型接口,其泛型参数 T 对应表格数据的类型。由于我们在封装通用组件时无法预知用户传入的数据类型,所以使用 any 是合理的折中。
分页组件详解
el-pagination 的 layout 属性
layout 属性控制分页组件各部分的排列顺序和显示内容,这是配置分页样式的核心属性:
// layout 各模块说明
const defaultLayout = [
'total', // "共 X 条"
'sizes', // 每页显示条数选择器
'prev', // 上一页按钮
'pager', // 页码
'next', // 下一页按钮
'jumper', // "前往 X 页" 跳转
].join(',')
// 输出: "total, sizes, prev, pager, next, jumper"
typescript
layout 中各部分的排列顺序就是它们在界面上的从左到右显示顺序。
分页对齐方向控制
通过 pagination.align 属性控制分页组件的对齐方式,底层使用 Flex 布局实现:
| align 值 | CSS 类名 | 效果 |
|---|---|---|
left | flex justify-start | 左对齐 |
center | flex justify-center | 居中对齐 |
right | flex justify-end | 右对齐(默认) |
分页的 Partial 处理
pagination 属性使用 & Record<string, any> 来放宽类型约束,这样用户不需要传递 el-pagination 的全部 9 个必填属性。el-pagination 组件自身有默认值机制,未传递的属性会使用默认值。唯一建议用户始终传递的是 total(数据总条数),因为它没有合理的默认值。
国际化(i18n)配置
Element Plus 组件默认显示英文,需要配置中文语言包。
方式一:全局配置(推荐)
在根组件 App.vue 中用 ElConfigProvider 包裹整个应用:
<!-- App.vue -->
<template>
<ElConfigProvider :locale="elementLocale">
<RouterView />
</ElConfigProvider>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const elementLocale = computed(() => {
const lang = locale.value.toLowerCase()
return lang === 'zh-cn' ? zhCn : en
})
</script>
vue
这里有一个容易踩的坑:vue-i18n 的 locale 值可能是 zh-CN(带大写),而 element-plus 的语言包 key 是 zh-cn(全小写)。使用 .toLowerCase() 统一转换可以避免找不到语言包的问题。
方式二:不使用 i18n
如果项目不需要国际化,直接在页面组件的 title 属性中写中文即可,不需要配置 vue-i18n。ElConfigProvider 也可以省略,但分页组件的 "共 X 条"、"前往" 等文字会显示为英文。
页面中使用封装后的表格组件
创建测试页面
src/pages/
└── components/
└── table/
└── index.vue # 表格测试页面
text
<!-- src/pages/components/table/index.vue -->
<template>
<div>
<h3>{{ t('pages.tableBasic') }}</h3>
<VTable
:columns="columns"
:table-props="tableConfig"
:pagination="paginationConfig"
/>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import VTable from '~/components/table/v-table.vue'
import type { TableColumnType } from '~/components/table/types'
defineOptions({
name: 'PagesTableBasic',
})
const columns: TableColumnType[] = [
{ prop: 'date', label: 'Date' },
{ prop: 'name', label: 'Name' },
{ prop: 'address', label: 'Address' },
]
const tableData = [
{ date: '2026-05-01', name: 'Tom', address: 'No. 1, Grove St' },
{ date: '2026-05-02', name: 'Jack', address: 'No. 2, Grove St' },
{ date: '2026-05-03', name: 'Lucy', address: 'No. 3, Grove St' },
]
const tableConfig = reactive({
data: tableData,
border: true,
stripe: true,
})
const paginationConfig = reactive({
total: 50,
align: 'right' as const,
layout: 'total, sizes, prev, pager, next, jumper',
pageSizes: [10, 20, 50, 100],
})
</script>
vue
默认值注意事项
在封装组件时,withDefaults 中设置的默认值会覆盖 el-table 的内部默认值。如果发现某些属性表现异常(比如 showHeader 变成了 undefined 导致表头消失),需要在 withDefaults 中显式声明这些默认值。
以下是建议在 withDefaults 中显式声明的属性列表:
withDefaults(defineProps<VTableProps>(), {
tableProps: () => ({
border: false,
stripe: false,
fit: true,
showHeader: true, // 必须显式声明,否则可能变成 undefined
highlightCurrentRow: false,
emptyText: 'No Data',
defaultExpandAll: false,
tooltipEffect: 'dark',
showSummary: false,
tableLayout: 'fixed',
}),
})
typescript
常见问题与解决方案
1. 列配置类型报错
当 columns 数组中的对象无法正确匹配 TableColumnType 时,可能遇到类型不兼容的问题。解决方法是使用类型断言:
const columns = [
{ prop: 'date', label: 'Date' },
{ prop: 'name', label: 'Name' },
] as TableColumnType[]
typescript
或者在定义时就标注类型:const columns: TableColumnType[] = [...]。
2. 分页组件显示英文
根本原因是 ElConfigProvider 的 locale 没有正确配置。检查以下几点:
App.vue中是否用ElConfigProvider包裹了根组件locale值是否使用了.toLowerCase()统一大小写element-plus的语言包是否正确导入
3. 属性透传的局限性
v-bind="tableProps" 透传的是 props,不包含事件。如果需要透传 el-table 的事件(如 @selection-change、@row-click),有两种方式:
方式一:通过 defineEmits 手动转发
const emit = defineEmits<{
selectionChange: [val: any[]]
rowClick: [row: any, column: any, event: Event]
}>()
typescript
方式二:使用 useAttrs() 全量透传
import { useAttrs } from 'vue'
const attrs = useAttrs()
// 模板中: <el-table v-bind="{ ...tableProps, ...attrs }">
typescript
方式二更简洁,能自动透传所有事件和属性,但不利于类型检查。实际项目中推荐对常用事件使用方式一,确保类型安全。
封装思路总结
这次封装的核心思路是"属性透传 + 类型别名":
- 属性透传:通过
v-bind将 props 整体传递给el-table和el-pagination,避免逐个属性绑定的重复代码。 - 类型别名:将
TableColumnCtx<any>导出为TableColumnType,使用时无需再从element-plus内部路径导入。 - 扩展属性:在
pagination中扩展了align属性,控制分页组件的对齐方向。 - 默认值保护:在
withDefaults中显式声明所有布尔类型的默认值,防止因undefined导致组件行为异常。
这个基础版本还缺少一些常用功能:单元格内容格式化(switch、tag、icon)、操作列的自定义渲染、表格数据请求的封装等。这些将在后续课程中逐步完善。在思考这些功能时,可以提前想一想:当数据库中存储的是 0/1 但需要显示为开关组件,或者存储的是状态码但需要显示为不同颜色的标签时,怎样的 column 配置设计能让使用者最方便?
↑